Thinking In Graph QL

Thinking in GraphQL

Created: January 13, 2022 11:49 AM

GraphQL은 프로덕트 개발자와 클라이언트 어플리케이션의 요구사항에 집중하여 데이터를 패치하는 새로운 방법을 제시한다.

개발자가 뷰에 필요로 하는 정확한 데이터를 구체화하고 하나의 네트워크 요청을 통해 그 데이터를 가져올 수 있도록 하는 방법을 제공한다.

자원 기반의 REST 접근방식과 같은 기존의 방법과 비교해보면, GraphQL은 데이터를 더욱 효율적으로 패치하게끔 돕고 custom endpoint를 이용함에 따라 중복되는 서버 로직을 줄일 수 있다.

게다가, GraphQL은 프로덕트 자체의 코드와 서버 로직을 분리할 수 있게끔 돕는다.

예를 들어, 프로덕트는 필요로 하는 정보와 관련된 서버 endpoint의 변경 없이 패치하는 데이터를 조절할 수 있다.

이 글을 통해 GraphQL 클라이언트 프레임워크를 만드는 의미와 전통적인 REST 시스템과 비교되는 점을 알 수 있다. 또한 Relay 기저에 깔린 디자인 결정적인 요소와 GraphQL 클라이언트로써 뿐만 아니라 명시적인 데이터 패칭 프레임워크로써의 Relay를 알아보고자 한다.

데이터 패칭

다음과 같이 스토리 리스트와 각 스토리의 디테일을 패치하는 간단한 어플리케이션이 있다고 가정해보자.

기존의 REST는 다음과 같다.

// 스토리 ID 리스트만 패치하고
// 디테일은 패치하지 않음
rest.get('/stories')
.then(stories =>
// 관련된 자원의 링크를 반환
// `[ { href: "http://.../story/1" }, ... ]`
Promise.all(stories.map(story =>
rest.get(story.href) // Follow the links
))
).then(stories => {
// 스토리 아이템(디테일)을 반환
// `[ { id: "...", text: "..." } ]`
console.log(stories);
});

이 과정은 서버에 n+1 번의 요청을 보낸다.

  • 1번은 리스트를 패치하기 위함

  • n번은 각각의 아이템을 패치하기 위함

GraphQL을 이용하여, 커스텀 endpoint를 생성하지 않고도(이를 생성할 시 유지해야 한다.) 서버에 단 한 번의 네트워크 요청을 통해 위와 같은 데이터를 패치할 수 있다.

graphql.get(`query { stories { id, text } }`).then(
stories => {
// 스토리 아이템을 담은 리스트
// `[ { id: "...", text: "..." } ]`
console.log(stories);
}
);

여기까지는 REST 접근보다 좀 더 효율적인 버전으로 GraphQL을 사용하는 것과 같다.

이렇게 하면 2가지의 중요한 이점이 있는데,

  1. 모든 데이터는 한번의 round trip으로 가져올 수 있다.
  2. 클라이언트와 서버가 분리될 수 있다: 클라이언트는 서버의 endpoint에 의존해 데이터를 가져오지 않고 직접 필요한 데이터를 구체화 할 수 있다.

클라이언트 캐싱

서버에서 같은 정보를 다시 패칭하는 것은 꽤 느릴 수 있다.

예를 들어, 스토리 리스트부터 하나의 아이템까지 찾아 내려가고 다시 스토리 리스트를 찾기 위해 되돌아오는 경로는 결국 리스트 자체를 다시 패치해야 함을 의미한다.

GraphQL은 “캐싱”이라는 해결책을 제시한다.

자원 기반의 REST 시스템의 경우, URI에 기반하여 응답 캐시를 유지한다.

var _cache = new Map();
// uri
rest.get = uri => {
if (!_cache.has(uri)) {
_cache.set(uri, fetch(uri));
}
return _cache.get(uri);
};

이러한 응답 캐싱은 GraphQL에도 적용 가능하다.

가장 기본적인 접근 방식은 REST 버전과 비슷하다.

이 때 쿼리 (텍스트) 자체가 캐시 키가 될 수 있다.

var _cache = new Map();
// queryText
graphql.get = queryText => {
if (!_cache.has(queryText)) {
_cache.set(queryText, fetchGraphQL(queryText));
}
return _cache.get(queryText);
};

이전에 캐시된 데이터를 요청할 경우, 추가적인 네트워크 요청 없이 GraphQL은 즉각적으로 캐시된 데이터를 제공한다.

이 실용적인 방식은 어플리케이션의 지각되는 performance를 향상시킬 수 있다.

하지만, 이러한 캐싱 방법은 데이터 안정성의 측면에서 문제를 발생시킬 수 있다.

캐시 일관성

GraphQL을 사용하다 보면, 여러 쿼리의 결과가 중복되는 것을 쉽게 볼 수 있다.

하지만, 이전 섹션의 응답 캐시는 이러한 중복과 관련이 없다 - 응답 캐시는 개별적인 쿼리에 기반하여 캐싱된다.

예를 들어, 스토리를 패치하기 위해 쿼리를 실행한다고 해보자.

query {
stories {
id,
text,
likeCount
}
}

이후, likeCount 가 증가한 스토리 하나를 다시 패치한다고 해보자.

query {
stories(id: "123") {
id,
text,
likeCount
}
}

여기서 스토리가 접근된 방식이 다르기 때문에, 우리는 다른 likeCount 를 확인할 수 있다.

첫번째 쿼리를 사용한 뷰는 구식의 count 데이터를 가지고 있을 것이고, 두번째 쿼리를 이용한 뷰는 업데이트 된 count 정보를 가지고 있을 것이다.

그래프 캐싱

GraphQL을 캐싱하는 방법은 계층적인 응답을 플랫한 레코드 집합으로 정규화 하는 것이다.

Relay는 이렇게 정규화한 캐시를 ID와 Record를 매핑하여 저장한다

각각의 레코드는 필드 이름에서 필드 값으로 매핑되어 있따.

레코드는 다른 레코드로 연결되어 있으며, 이는 순환적인 그래프를 묘사할 수 있다. 이러한 연결 링크는 최상위 맵을 다시 참조하는 구체적인 키 값으로 사용된다.

이러한 접근 방식을 이용해, 각각의 서버 레코드는 어떻게 패치되었느냐에 관계없이 단 한 번 저장된다.

스토리 텍스트와 작가 이름을 패치하는 쿼리가 있다고 해보자.

query {
story(id: "1") {
text,
author {
name
}
}
}

다음은 응답 결과이다.

{
"query": {
"story": {
"text": "Relay is open-source!",
"author": {
"name": "Jan"
}
}
}
}

응답 결과는 계층적일 지라도, GraphQL은 모든 레코드를 평면화하여 캐시한다. Relay가 이러한 쿼리 결과를 캐시하는 방법은 다음과 같다.

Map {
// `story(id: "1")`
1: Map {
text: 'Relay is open-source!',
author: Link(2),
},
// `story.author`
2: Map {
name: 'Jan',
},
};

이건 간단한 예시이고, 실제로 캐시는 1:다의 관계와 페이지네이션 및 다른 것들을 동시에 핸들링 해야 한다.

캐시 사용하기

그래서 우리는 이러한 캐시를 어떻게 사용할 수 있을까?

응답을 받으면 캐시를 쓰고, 쿼리를 지역적으로 사용할 수 있는지 결정하기 위해 캐시에서 데이터를 읽는다. (이는 _cache.has(key) 와 동일하지만, graph 상에서 작동한다.)

캐시 분포시키기

캐시를 분산시키는 것은 GraphQL 응답의 계층을 따라가며 정규화된 캐시 레코드를 생성하고 업데이트하는 과정을 포함한다.

처음에는, 응답 자체로써 충분해 보이지만, 이것은 매우 간단한 쿼리에만 해당한다.

user(id: "456") { photo(size: 32) { uri } }

위의 경우에서 photo 를 어떻게 저장할까?

photo 를 캐시의 필드 이름으로 사용하면 작동하지 않을 것이다. 왜냐하면 다른 쿼리는 같은 필드를 패치하지만 다른 인자값을 사용할 것이기 때문이다.

페이지네이션에서도 같은 문제가 발생한다.

stories(first: 10, offset: 10) 를 통해 11~20번의 스토리를 패치하면, 이 새로운 값은 이미 존재하는 리스트에 붙여야 한다.

그래서, 정규화된 응답 캐시는 payload와 쿼리를 병렬적으로 처리하는 과정이 필요하다.

예를 들어, 위의 photo 필드는 unique한 필드와 인자값을 구별해내기 위해 photo_size(32) 와 같이 생성된 필드 이름과 함께 캐시된다.

캐시 읽기

캐시로부터 데이터를 읽기 위해서는 쿼리를 실행한 후 각 필드를 resolve 하면 된다.

근데 이건 정확히 GraphQL 서버가 쿼리를 처리하는 과정과 똑같아 보인다. 그리고 맞다!

캐시로부터 데이터를 읽는 것은

  1. 모든 결과가 고정된 데이터 구조로부터 오기 때문에 user-defined 필드 함수가 필요하지 않다.
  2. 결과는 항상 동기화된다. - 데이터는 캐시되었거나 가지고 있지 않다.

는 점에서 특별한 실행 과정을 의미한다.

Relay는 Query traversal을 위해 여러 이형을 갖고 있다. Query traversal이란 쿼리와 함께 캐시나 응답 payload와 같은 데이터도 검사하는 operation을 의미한다.

  1. 예를 들어, 쿼리가 패치되었을 때, Relay는 diff traversal을 통해 어떤 필드가 빠졌는지 결정한다. (이는 리액트가 가상 돔 트리를 비교하는 것과 비슷하게 작동한다.)

    이는 패치된 데이터의 양을 줄이고 Relay로 하여금 모든 쿼리가 캐시된 상태에서 불필요한 네트워크 요청을 피한다.

캐시 업데이트

이렇게 정규화된 캐시 구조는 반복되는 결과를 중복 없이 처리하도록 한다.

각각의 레코드는 어떻게 패치되었는지와 관계없이 단 한 번 저장된다.

일정하지 않은 데이터 예시를 통해 이러한 시나리오에서 어떻게 캐시가 작동하는지 보자.

첫번째 쿼리는 스토리 리스트였다:

query {
stories {
id,
text,
likeCount
}
}

정규화된 캐시 응답을 통해, 레코드는 리스트의 각 스토리마다 생성된다.

stories 필드는 이러한 레코드 각각에 링크된다.

두번째 쿼리는 그 중 하나의 스토리를 refetch 한다.

query {
stories(id: "123") {
id,
text,
likeCount
}
}

이러한 응답이 정규화 되면, Relay는 id 에 기반하여 존재하는 데이터와 중복되는지 감지한다.

새로운 레코드를 생성하는 것보다, Relay는 기존의 123 레코드를 업데이트한다.

새로운 likeCount 는 양 쪽의 쿼리 모두에서 이용 가능하며, 해당 스토리를 모두 참조할 수 있다.

데이터/ 뷰 일관성

정규화된 캐시는 캐시의 일정함을 보장한다.

하지만 뷰는 어떨까?

이상적으로는, React 뷰는 캐시로부터 최신의 정보를 항상 반영해야 한다.

스토리의 텍스트와 코멘트를 작기 이름, 사진과 함께 렌더링한다고 가정해보자. 이에 대한 GraphQL 쿼리는 다음과 같다.

query {
story(id: "1") {
text,
author { name, photo },
comments {
text,
author { name, photo }
}
}
}

이 스토리를 초기에 패칭한 후의 캐시는 다음과 같을 것이다. 이 때 스토리와 코멘트는 author 과 같은 레코드와 연결된다는 것을 명심하자.

Map {
// `story(id: "1")`
1: Map {
text: 'got GraphQL?',
author: Link(2),
comments: [Link(3)],
},
// `story.author`
2: Map {
name: 'Yuzhi',
photo: 'http://.../photo1.jpg',
},
// `story.comments[0]`
3: Map {
text: 'Here\'s how to get one!',
author: Link(2),
},
}

이 스토리의 작가는 댓글도 달았을 것이다. 그렇다면 다른 뷰가 이 작가에 대한 새로운 정보를 패치했다고 가정할 때, 그녀의 프로필 사진이 새로운 URI로 변경되었다고 해보자.

다음은 캐시된 데이터에서 변경된 유일한 부분이다.

Map {
...
2: Map {
...
photo: 'http://.../photo2.jpg',
},
}

photo 필드 값은 변경되었고, 따라서 레코드 2 또한 변경되었다고 볼 수 있다. 그리고 이게 전부다!

다른 캐시 정보는 영향을 받지 않는다.

하지만 우리 뷰는 이러한 업데이트를 반영해야 한다: UI의 스토리와 코멘트 모두에서 작가의 새로운 사진을 보여줘야 하기 때문이다.

일반적인 답은 “immutable data structure를 이용하라” 일 것이다. 하지만 그렇게 할 시,

ImmutableMap {
1: ImmutableMap // same as before
2: ImmutableMap {
... // other fields unchanged
photo: 'http://.../photo2.jpg',
},
3: ImmutableMap // same as before
}

2 를 새로운 immutable 레코드로 대체한다면, 캐시 객체에 대한 새로운 immutable 인스턴스를 얻게 될 것이다. 하지만 레코드 13 은 변경되지 않는다.

데이터가 정규화되었기(중복이 제거되었기) 때문에, story 만 보고서는 스토리의 컨텐츠 또한 변경되지 않았다고 확신할 수 없다.

뷰 일관성 달성하기

평면화된 캐시를 이용해 뷰를 최신 버전으로 업데이트 할 수 있는 다양한 해결책이 존재한다.

Relay는 각각의 UI 뷰와 뷰가 참조하는 ID 세트를 매핑하여 유지하는 방법을 채택한다.

이러한 경우, 스토리 뷰는 스토리와 작가, 그리고 코멘트 등이 업데이트 되었는지 구독할 것이다.

데이터를 캐시에 쓸 때, Relay는 어떤 ID가 영향을 받는지 트래킹하고 영향을 받는 ID와 연관된 뷰에게만 notify 할 것이다.

이렇게 영향을 받는 뷰는 리렌더링 되고, 영향을 받지 않는 뷰는 성능 향상을 위해 리렌더링을 하지 않는다. (Relay는 안전하지만 효과적인 shouldComponentUpdate 를 기본으로 제공한다.)

이러한 전략 없이는 모든 뷰가 작은 변화에도 리렌더링 될 것이다.

캐시가 업데이트 될 때 영향을 받는 뷰에 notify 하고, write는 그렇게 캐시를 업데이트 하는 하나의 방법이기 때문에, 이러한 해결책은 캐시를 작성함에 있어서도 적용된다.

Mutations

지금까지는 데이터를 쿼리하고 뷰를 최신 버전으로 업데이트하는 과정을 보았지만, write에 대해서는 알아보지 않았다.

GraphQL에서는, write를 mutation 이라 칭한다.

이는 side effect가 있는 쿼리라고 생각하면 된다.

다음은 특정 스토리가 현재 유저에 의해 liked된 상태를 표시하는 mutation을 발생시키는 예제이다.

mutation StoryLike($storyID: String) {
// mutation 할 필드를 호출하고 변경할 side effect를 발생시킨다.
storyLike(storyID: $storyID) {
// mutation 실행 이후 re-패치할 필드를 정의한다.
likeCount
}
}

이는 mutation의 결과로 변경된 데이터를 쿼리하는 것임을 명심하자.

그렇다면 명백한 질문은 다음과 같다:

왜 서버는 어떤 것이 변경됐는지 그냥 알려주지 않는가?

답은:

복잡하기 때문이다.

GraphQL는 여러 소스의 집합이나 데이터 storage layer를 추상화 하기 때문에, 모든 프로그래밍 언어와 작용할 수 있다.

게다가, GraphQL의 목적은 프로덕트 개발자가 뷰를 완성하는데 유용한 형태로 데이터를 제공함에 있다.

GraphQL 스키마는 데이터가 어떤 디스크에 저장되어 있는지에 따라 다를 수 있다.

간단히 말해서: 데이터가 저장되어 있는 저장소 (디스크) 와 product-visible 스키마 (GraphQL) 는 1:1로 매칭되지 않는다.

이를 설명할 수 있는 완벽한 예시는 프라이버시이다: age 와 같이 유저에 대한 필드를 반환하는 경우, 현재 활성화된 유저가 그 age 필드를 볼 수 있는지 결정하기 위해 data-storage 레이어에 저장된 수많은 레코드에 접근해야 한다. (친구인가? 나이가 공유되는가? 차단했는가? 등을 고려해야 함.)

이러한 현실적인 제약을 고려했을 때, GraphQL 접근법은 mutation 이후에 변경될 데이터를 클라이언트가 쿼리하기에 적합하다.

하지만 정확히 쿼리에 무엇을 넣어야 할까?

Relay를 개발하는 동안 여러 아이디어가 있었다 - 왜 Relay가 이러한 접근법을 사용하는지 고려했던 여러 옵션들을 통해 간단히 알아보자:

  • 앱이 쿼리했던 모든 정보를 re-패치한다. 데이터의 작은 subset이 변경되었다 하더라도, 서버가 전체 쿼리를 수행하고 결과를 다운받고, 이 과정을 반복하기까지 기다려야 한다. 매우 비효율적인 방안이다.
  • 현재 활성화된 렌더링 뷰에서 필요로 하는 쿼리만 다시 패치한다. 옵션1보다 약간의 향상은 기대할 수 있지만, 아직까지 확인하지 않았던 데이터 중 캐시된 데이터는 업데이트 되지 않는다. 이 데이터가 어떻게든 변경되었다고 표시되지 않는 한, 이후의 쿼리는 구식의 데이터를 읽을 것이다.
  • mutation 이후에 변경될 수 있는 고정된 리스트의 필드 값들만을 다시 패치한다. 이를 fat query 라고 한다. 전형적인 어플리케이션의 경우 이 중 일부만을 렌더링하지만, 이 접근법은 모든 필드를 패칭해야 하기 때문에 비효율적이다. (다시 패칭한 값 중 사용하지 않을 값들이 많을 가능성이 높음.)
  • 어떤 것이 변할지에 대한 정보를 담은 교차부분과 캐시의 데이터만을 다시 패칭한다. (fat query) 여기에 더해서, Relay는 각 아이템을 패치하기 위해 사용된 쿼리 또한 기억한다. 이를 tracked queries 라고 일컫는다. 이러한 tracked & fat queries 의 교집합을 이용해, Relay는 어플리케이션에서 업데이트 할 정보만을 쿼리할 수 있다.

데이터 패칭 APIs

지금까지 데이터 패칭을 위한 저레벨 측면과 익숙한 여러 컨셉이 어떻게 GraphQL로 변환되었는지 알아보았다.

다음번엔, 고차원적인 개념을 통해 프로덕트 개발자가 데이터 패칭을 하는 과정에서 직면하는 여러 문제를 알아보자:

  • 모든 데이터를 뷰 계층의 측면에서 패칭한다.
  • 비동기적인 상태 이전과 동시다발적인 요청을 관리한다.
  • 에러를 컨트롤한다.
  • 실패한 요청을 다시 요청한다.
  • query/ mutation 응답을 받은 후의 지역 캐시를 업데이트한다.
  • race condition을 피하기 위해 mutation을 큐잉한다.
  • 서버로부터 mutation에 대한 응답을 기다리는 동안 UI 업데이트를 최적화한다.

imperative API를 이용해 데이터를 패칭하는 전형적인 방법은 개발자로 하여금 불필요하게 높은 복잡성을 감당하도록 한다.

예를 들어, UI 업데이트를 최적화하는 것을 생각해보자.

이는 서버 응답을 기다리는 동안 유저에게 피드백을 주는 것이 될 수 있다.

어떤 것을 해야하는지는 꽤 분명하다: 유저가 “like” 버튼을 누르면, 스토리를 liked 처리하고 서버에 요청을 보낸다.

하지만 구현은 보통 더 복잡하다.

UI에 도달한 후 버튼을 토글하고, 네트워크 요청을 개시하고 필요할 경우 이를 다시 시도하며, 만약 실패할 시 에러를 보여줘야 한다. 또 버튼을 언토글해야 한다. 등등이 있다.

데이터 패치도 위와 동일하다: 어떤 데이터를 필요로 하는지 구체화하는 것은 보통 어떻게, 언제 데이터가 패치되는지 나타내야 한다.

이러한 고민을 Relay로 해결할 수 있는 방법을 다음 장에서 알아볼 것이다.

To be continued...